29 GLB_GLTF 3D模型加载基础
GLB/GLTF 3D模型加载基础
关联:索引
要解决的问题
- 为什么工业 3D 场景进入“真实设备展示”阶段后,不能一直只靠 BoxGeometry / CylinderGeometry 手工搭建?什么时候应该转向模型加载?
- GLB 和 GLTF 都叫 “glTF 家族”,但它们到底差在哪:为什么有的项目更适合单文件
.glb,有的项目会保留.gltf + 贴图结构? - 一个模型从“磁盘里的文件”到“场景里可见的对象”,中间到底经历了哪些环节:加载、解析、挂载、定位、缩放、渲染分别是谁负责?
- 为什么模型“明明加载成功了却看不见”:是路径错了、比例不对、坐标跑偏了,还是相机/光照没有对上?
- 进度条为什么有时能显示百分比,有时只能显示“加载中”:
xhr.total什么时候可信,什么时候拿不到? - 工业场景里如果一次要加载机械臂、传送带、分拣箱 3 个模型,如何避免“页面刚打开就卡顿、白屏、报错难排查”?
- 如果我的最终目标是“机器人关节联动前端模型”,为什么本讲要先强调模型层级与命名?哪些准备工作会决定后续联动是不是顺畅?
本讲定位(与上一讲衔接,避免重复)
- 已具备:Three.js 最小渲染闭环、基础几何体与材质、环境光/方向光、阴影的最小工程化配置。
- 本讲新增:工业 3D 模型常用格式(GLB/GLTF)的区别与选型;
GLTFLoader的引入与使用;模型加载、解析、添加到场景的完整流程;加载进度监听、失败处理、基础性能优化与测试口径。 - 本讲不重复:Scene / Camera / Renderer / 光照从零创建的原理推导;基础几何体建模;复杂交互控制器;高级压缩与后处理(后续可扩展)。
- 本讲为下一阶段铺路:后续如果要做“真实设备上屏、数字孪生、ROS2/Gazebo 模型可视化”,模型加载能力是必备基础。
章节内容(本讲核心)
- 工业 3D 模型常用格式:GLB / GLTF 的特点、差异与适用场景
GLTFLoader核心插件:引入方式、最小加载流程、常见返回对象结构- 模型加载流程:加载文件、解析内容、获取
gltf.scene、添加到场景、调整位置与缩放 - 加载进度监听与失败处理:
onProgress/onError的最小工程化方案 - 模型加载的基础性能优化:单文件优先、像素比封顶、按需加载、重复模型复用、资源释放
- 模型加载测试用例设计:加载成功率、加载时间、错误提示、位置缩放适配
- 为关节联动预留接口(本讲扩展认知):模型层级与命名、节点索引与映射、滑块模拟关节旋转(不接 ROS2)
本讲边界说明
- 本讲解决的是“模型资产如何进入前端场景并被正确显示”,重点是格式认知、加载流程、路径组织、进度提示和基础排错。
- 如果模型来源于 Gazebo、工业建模工具或数字孪生资源库,本讲同样适用,因为前端首先要解决的是“把模型看见”。
- 本讲暂不接入 ROS2 实时数据链路,因此不覆盖“从 ROS2 话题驱动关节角”的完整联动实现;但会做“联动预埋点”的最小演示(例如打印模型节点树、建立节点映射、用滑块模拟一个关节旋转),帮助你理解后续联动控制的总体思路。
- 上述动态能力将在后续 ROS2 Web 数据交互课程中展开,到时会把“已加载好的模型”作为前端显示载体,再接入实时数据进行驱动。
- 因此,学习顺序应理解为:先完成
GLB / GLTF 模型显示,再完成ROS2 / Gazebo 状态同步,两者是前后衔接关系,而不是互相替代关系。
环境与先修(默认沿用上一讲工程)
先修要求:
- 已有 Vue3 + Vite + TypeScript 工程,并已安装
three。 - 已能在页面中渲染基础场景,至少包含相机、渲染器、辅助线以及基础光照。
- 已理解
position / rotation / scale的作用,能手动调整物体姿态。
如你需要补装依赖(仅在未安装时执行):
# 安装 Three.js 运行时依赖
npm i three
# 仅在 TypeScript 报 “找不到声明文件” 时再安装(老工程更常见)
npm i -D @types/three
解释:
npm i three:安装 Three.js 运行时库。GLTFLoader不需要单独再装一个 npm 包,它位于three/examples/jsm/loaders/GLTFLoader.js。- 如果你的工程已经安装过
three,这一条不必重复执行。 - 本讲默认继续沿用上一讲的 Vue3 + Vite + TypeScript 工程,不重新创建项目,减少重复操作。
可复制运行:最小工程骨架(没有现成工程时使用)
- 创建工程
# 在当前目录创建一个新的 Vue3 + TS 工程(名字可自定义)
npm create vite@latest gltf-lab -- --template vue-ts
cd gltf-lab
解释:
npm create vite@latest:用 Vite 创建工程骨架。--template vue-ts:选择 Vue 3 + TypeScript 模板。
- 安装依赖
npm i
npm i three
解释:
- 第一条安装工程自身依赖,第二条安装 Three.js。
GLTFLoader属于three/examples,不需要额外安装 npm 包。
- 放置模型文件
gltf-lab/
└─ public/
└─ models/
└─ robot-arm.glb
解释:
- 本讲建议把模型放到
public/models/,这样浏览器可以用 URL 直接访问。 - 如果你手头不是机械臂模型,也可以先把任意
.glb文件重命名为robot-arm.glb。
src/main.ts(可直接复制):
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
// Vue 应用入口:把根组件挂载到 index.html 的 #app 容器
createApp(App).mount('#app');
解释:
- 如果你创建的新工程只有
style.css,可以把文件改名为styles.css,或把这里的 import 改回./style.css。
src/App.vue(可直接复制:提供单模型/批量/关节预埋三套实验切换):
<template>
<main class="app">
<h1>GLB / GLTF 模型加载实验</h1>
<!-- 顶部 tabs:三套实验在同一工程里切换,便于课堂对照与排错 -->
<nav class="tabs">
<button
v-for="lab in labs"
:key="lab.key"
type="button"
:class="['tab', currentLab === lab.key ? 'tab--active' : '']"
@click="currentLab = lab.key"
>
{{ lab.label }}
</button>
</nav>
<GltfSingleLoadLab v-if="currentLab === 'single'" />
<GltfBatchLoadLab v-else-if="currentLab === 'batch'" />
<GltfJointPrepLab v-else />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// 三个实验组件:单模型加载 / 批量加载 / 关节联动预埋(滑块模拟)
import GltfBatchLoadLab from './components/GltfBatchLoadLab.vue';
import GltfJointPrepLab from './components/GltfJointPrepLab.vue';
import GltfSingleLoadLab from './components/GltfSingleLoadLab.vue';
type LabKey = 'single' | 'batch' | 'joint';
// tabs 配置:只改这里就能调整显示顺序或文案
const labs: Array<{ key: LabKey; label: string }> = [
{ key: 'single', label: '单模型加载' },
{ key: 'batch', label: '批量加载' },
{ key: 'joint', label: '关节预埋' },
];
// 当前选中的实验页签
const currentLab = ref<LabKey>('single');
</script>
<style scoped>
.app {
display: grid;
gap: 12px;
padding: 12px;
background: #0b1220;
color: #e5e7eb;
min-height: 100vh;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tab {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 999px;
padding: 8px 14px;
background: rgba(15, 23, 42, 0.84);
color: #e2e8f0;
cursor: pointer;
font-weight: 700;
}
.tab--active {
border-color: rgba(56, 189, 248, 0.65);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12);
}
</style>
解释:
- 通过顶部 tabs 切换三套实验:先跑通单模型,再做批量加载,最后做关节联动预埋(滑块模拟)。
- 启动开发服务器
npm run dev
解释:
- 浏览器打开命令输出的本地地址(通常是
http://localhost:5173/)。 - 如果页面空白或报错,优先按的“最短排错路径”查:路径 → 网络请求 → 是否 add 到 scene → 缩放/位置 → 光照。
public/
└─ models/
├─ robot-arm.glb
├─ conveyor.glb
└─ sorting-line.glb
解释:
robot-arm.glb:单模型加载的最小可运行资源。- 进阶补充:如果你要演示
.gltf + .bin + textures的多文件结构,可以把sorting-line.glb替换为.gltf版本,并确保.bin/贴图按相对路径一并放入,否则会触发 404 或解析失败。
在上一讲里,我们已经能用基础几何体搭出工作台、立柱、工件,也能用灯光和阴影让场景看起来更像工业环境。但真实项目很快会遇到一个问题:
- 机械臂、AGV、传送带、分拣装置这类设备,结构复杂、层级多、细节明显,继续手工用基础几何体拼接,成本会快速上升。
- 同一个设备模型,往往要被多个页面、多个项目、多个联调场景重复使用,“一次建模、多处复用”才符合工程习惯。
- 工业 3D 项目的常见工作方式不是“前端自己把所有设备重新建一遍”,而是接收建模同学或资源平台提供的标准模型,再由前端负责加载、摆放、状态展示与交互。
工程结论:
从这一讲开始,Three.js 的重点不再只是“手工画出一个物体”,而是“把已有工业模型稳定地加载进来,并让它可控、可测、可排错”。
1) 一句话理解 glTF 家族
glTF可以理解为“面向运行时展示的 3D 传输格式”,目标是让模型更适合 Web、实时渲染和跨工具流转。- 在 Three.js 里,我们最常见的是两种落地形式:
.glb:二进制单文件.gltf:JSON 描述文件,常配合.bin与贴图文件一起使用
2) GLB 与 GLTF 的对比表
| 对比项 | GLB | GLTF |
|---|---|---|
| 文件组织 | 单文件 | 通常是 .gltf + .bin + 贴图 |
| 传输便利性 | 更高,拷贝/部署简单 | 需要保证关联资源路径完整 |
| 可读性 | 二进制,不适合直接阅读 | JSON 文本,可查看结构 |
| 工业项目建议 | 优先作为前端交付格式 | 适合保留原始结构和资源引用关系 |
如果你只想“先把模型跑起来”,优先选
.glb;如果你要分析资源结构、保留多贴图和外部文件关系,.gltf更直观,但路径管理更严格。
3) 工业场景里的选型建议
- 多资源拆分、需要查看结构或和建模工具导出结果逐项对应:可以保留
.gltf - 网络环境一般、部署链路复杂时:尽量减少资源碎片,优先考虑
.glb - 无论使用哪一种,前端最终都需要把它通过
GLTFLoader解析成 Three.js 可用对象
4) 从 world / sdf 到 glb 的标准转换思路
如果你的模型来源不是现成的 .glb / .gltf,而是 Gazebo 使用的 .world 或 .sdf,要先建立一个正确认知:
.world/.sdf是仿真场景描述,不只是“一个模型文件”。- 它除了视觉模型,还可能包含物理参数、碰撞体、插件、传感器、关节、灯光、地面和场景组织信息。
- 因此,实际工作中通常不是“把整个
world一键导出成一个完整的glb”,而是先提取视觉资产,再转换成前端可加载的模型资源。
- 先打开
world或model.sdf,确认场景中到底引用了哪些模型。 - 查找
<include>、<model>、<visual>、<geometry>、<mesh><uri>这些关键节点。 - 找到真实的可视化资源文件,常见为
.dae、.obj、.stl等 mesh 文件。 - 对 mesh 文件做格式转换,整理材质、贴图、缩放和坐标轴后,导出为
.glb。 - 记录
sdf / world中的pose、scale、模型名称,供前端重新摆放场景使用。 - 前端用
GLTFLoader加载这些.glb,再根据记录好的位姿信息恢复场景布局。
你可以把这个流程概括为一句话:
不是直接把
world / sdf整体变成glb,而是“从world / sdf中提取视觉模型,再转换为glb,最后在前端重新组装场景”。
5) 转换时要优先识别的三类资源
在 Gazebo 场景里,最常见的是下面三类可视化来源:
-
外部 mesh 文件:如
.dae、.obj、.stl,这是最适合转换为.glb的部分。 -
SDF 原生几何体:如
<box>、<cylinder>、<sphere>、<plane>,这类往往不必专门转glb,在前端直接用 Three.js 基础几何体重建更轻量。 -
模型引用:如
<include><uri>model://xxx</uri></include>,这说明你还要继续找到对应的model.sdf和它引用的 mesh 资源。 -
机械臂、AGV、传送带、工作站外壳这类复杂设备,优先走“提取 mesh -> 转
.glb”流程。 -
地面、简单立方体障碍物、基础圆柱支架等简单几何体,不一定需要转
.glb,直接前端重建即可。 -
不要把插件、传感器、碰撞参数也当成模型资产来转换,它们属于仿真逻辑,不属于本讲的前端模型加载范围。
6) 推荐的人工转换工具与操作顺序
- 导入
.dae/.obj/.stl - 检查贴图是否丢失
- 调整模型朝向和单位尺度
- 导出为前端更友好的
.glb
推荐操作顺序:
world / sdf
→ 找到 visual 对应的 mesh
→ 导入 Blender
→ 检查材质 / 贴图 / 缩放 / 朝向
→ 导出为 glb
→ 前端通过 GLTFLoader 加载
解释:
world / sdf:这里负责告诉你“场景里有哪些对象、它们原本放在哪里”。找到 visual 对应的 mesh:这是把“仿真描述”落到“真实模型文件”的关键一步。导入 Blender:用于做格式中转和基础整理,而不是重新建模。检查材质 / 贴图 / 缩放 / 朝向:这是转换成功率最高的四个检查点,任何一个出问题,前端显示都可能异常。导出为 glb:得到最终适合浏览器加载的单文件模型。前端通过 GLTFLoader 加载:回到本讲主线,把转换后的模型接入 Three.js 工程。
7) 转换后的常见风险提醒
- 风险 1:坐标轴不一致。Gazebo、建模工具、Three.js 的朝向约定可能不同,导出后模型可能会“躺下”或朝向错误。
- 风险 2:单位不一致。模型在 Gazebo 中大小正常,导出到前端后可能过大或过小,需要重新核对缩放比例。
- 风险 3:贴图丢失。
.dae、.obj往往依赖外部贴图文件,转换前要确认材质资源已正确加载。 - 风险 4:不要误把整个仿真逻辑当成 glb。
glb解决的是“看起来是什么样”,不负责保存 Gazebo 插件、物理规则和 ROS2 通信逻辑。
对前端来说,
world / sdf -> glb的本质不是“仿真文件格式转换”,而是“把 Gazebo 中的可视资产整理成浏览器可加载的模型资源”。
8) 从 xacro / urdf 到 glb:为“关节联动”准备的资产规则(扩展认知)
如果你的机器人来自 xacro / urdf(Gazebo 和 ROS2 中最常见),要先建立一个对“联动控制”非常关键的认知:
xacro / urdf主要描述机器人结构(link / joint)与显示/碰撞信息,它们本身不是浏览器最适合加载的模型资产。- 前端要做关节联动,核心不是“模型能显示”这么简单,而是:模型里必须保留可被定位和旋转的“关节相关节点”,并且节点的旋转枢轴与旋转轴要合理。
xacro -> urdf:先把宏展开成可读的urdf(方便检查 link/joint/visual)。urdf -> 找 visual mesh:在每个 link 的<visual>中找到真正的 mesh 资源(常见.dae/.stl/.obj)。mesh -> glb:用工具把 mesh 转为.glb,并检查贴图、缩放、朝向。- “联动预埋”检查:确保导出的
.glb保留了合理的层级与节点命名,便于前端建立映射并驱动局部旋转。
为什么这里要强调“层级与命名”:
- 如果你把机器人所有零件合并成一个单一 mesh(或导出时把层级打平),前端虽然能加载显示,但很难只转动某一个关节对应的部件。
- 如果你的
.glb里关键节点没有稳定的name(或被导出工具改名),后续把 ROS2 的joint_states映射到前端对象会非常痛苦。
把下面 5 条当成“联动能否顺利”的硬指标:
- 层级存在:机器人至少是“多节点对象树”,而不是一个无法拆分的单 mesh。
- 命名稳定:关键节点(link 或关节对应的旋转组)有稳定可读的
name,并尽量与 URDF 的 link/joint 命名保持一致。 - 枢轴正确:关节旋转的支点(pivot)在合理位置(通常接近关节轴位置),否则前端旋转会出现“绕空气转圈”。
- 轴向明确:关节旋转轴方向一致(例如绕 Z 轴或绕某一固定轴),否则即使角度对了,视觉也会乱。
- 可验证:前端加载后能通过“打印节点树 + 手动旋转一个节点”的方式验证联动是否可行。
这一讲不要求你把“URDF joint axis”严格还原到前端(那属于后续 ROS2 联动课程),但至少要做到:你能在前端选中一个节点并让它旋转,旋转效果看起来“像在转关节”。
GLTFLoader 的定位很简单:
- 它负责读取
.glb/.gltf文件 - 它负责把文件解析成 Three.js 可以识别的对象结构
- 解析完成后,你最常用的入口是
gltf.scene
最小导入方式:
import * as THREE from 'three'; // Three.js 核心包(Scene/Camera/Renderer 等都从这里来)
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // glTF 家族加载器(注意 .js 后缀)
解释:
-
import * as THREE from 'three':导入 Three.js 核心对象,后续还要用到Scene、PerspectiveCamera、WebGLRenderer、AmbientLight等类。 -
GLTFLoader不是从three主入口直接导入,而是从three/examples/jsm/loaders/GLTFLoader.js导入。 -
导入路径里的
.js不能省略,这是很多同学在 Vite + TypeScript 工程里的高频报错点之一。 -
同一页面往往不止一个模型
-
不同模型大小、原点、朝向可能不一致
-
某个模型失败了,不应该把整个页面拖垮
-
要开始记录“到底加载成功了几个、总耗时是多少、失败原因是什么”
工程结论:
工业项目中,模型加载不是“能出来就行”,而是要做到“部分失败可见、整体状态可知、问题可排查”。
如果你要一次加载多个模型,Promise 风格会更适合组织代码:
const loader = new GLTFLoader();
// loadAsync:Promise 风格加载(适合 async/await 写法)
const gltf = await loader.loadAsync(`${import.meta.env.BASE_URL}models/robot-arm.glb`);
// gltf.scene:模型根节点(Three.js 对象树),拿到后就可以 add 到场景
scene.add(gltf.scene);
解释:
loadAsync是GLTFLoader提供的 Promise 风格 API,更适合和async / await、Promise.all / Promise.allSettled结合。- 单模型入门时用
load比较直观;多模型工程化时,loadAsync更容易管理成功、失败与总耗时。 - 这不是两套不同能力,而是同一个加载器的两种组织方式。
扩展:为“关节联动”预埋(不接 ROS2,先用滑块模拟)
如果你的最终目标是“机器人关节与前端模型联动”,建议从这一讲就把下面三件事做成习惯:看层级、找节点、建映射。这三步决定了你后续能不能把 joint_states 的角度稳定映射到模型上。
- 打印模型节点树(找得到节点,才谈得上控制)
function dumpObjectTree(root: THREE.Object3D) {
// 用数组收集输出,避免 traverse 时频繁 console.log 造成输出难看/乱序
const lines: string[] = [];
root.traverse((obj) => {
const type = obj.type || 'Object3D';
const name = obj.name || '(no-name)';
lines.push(`${type} | ${name}`);
});
console.log(lines.join('\n'));
}
解释:
- 这段代码不是为了“炫技”,而是为了回答最关键的问题:你的
.glb里有没有稳定的节点名?层级有没有被导出工具打平? - 如果输出里全是
(no-name)或者只有一个大 Mesh,说明后续联动会很难,需要回到资产导出环节做“联动预埋”。
- 建立
name -> Object3D索引(后续把 joint 名映射到对象)
function buildNodeIndex(root: THREE.Object3D) {
// name -> Object3D 的索引表:后续 joint_states 进来时可 O(1) 找到要驱动的节点
const index = new Map<string, THREE.Object3D>();
root.traverse((obj) => {
if (!obj.name) return;
index.set(obj.name, obj);
});
return index;
}
解释:
- 未来你从 ROS2 收到的关节信息本质上是“关节名 + 角度”,前端第一步就是把“关节名”变成“要操作的 Object3D”。
- 这里用
Map是为了 O(1) 查找,避免每次更新都遍历整棵树。
- 用滑块模拟一个关节旋转(先验证“能转”,再谈“数据驱动”)
function setJointAngle(node: THREE.Object3D, angleRad: number) {
// 最小演示:先固定绕 Z 轴转(真实项目的旋转轴应来自 URDF joint axis / tf)
node.rotation.z = angleRad;
}
function degToRad(deg: number) {
// Three.js 的 rotation 使用弧度(rad),课堂滑块更常用角度(deg),所以需要换算
return (deg * Math.PI) / 180;
}
解释:
- 这一步不接 ROS2,只做“最小联动验证”:能不能把一个局部节点转起来,且旋转看起来像是“转关节”。
- 如果旋转出现“绕空气转圈”或“方向不对”,多数不是 Three.js 代码问题,而是资产侧的枢轴(pivot)或旋转轴没有准备好,需要回到导出环节调整。
可复制运行版(推荐):src/components/GltfJointPrepLab.vue
<template>
<div class="page">
<aside class="panel">
<h3>关节联动预埋(滑块模拟)</h3>
<p class="hint">
本组件不接 ROS2,只验证“找得到节点并能驱动局部旋转”。后续课程把滑块输入替换成 joint_states 即可。
</p>
<div class="row">
<label>目标节点名</label>
<input v-model="targetNodeName" class="input" placeholder="例如:joint_1 或 link_1" />
</div>
<div class="row">
<label>角度(度)</label>
<input v-model.number="angleDeg" type="range" min="0" max="180" step="1" />
<div class="value">{{ angleDeg }}°</div>
</div>
<div class="actions">
<button type="button" class="btn" @click="dumpTree" :disabled="!modelRoot">打印节点树</button>
<button type="button" class="btn" @click="applyAngle" :disabled="!modelRoot">应用角度</button>
<button type="button" class="btn" @click="reload" :disabled="loading">重新加载</button>
</div>
<p>状态:{{ loading ? '加载中' : modelRoot ? '已加载' : '未加载' }}</p>
<p v-if="notice" class="notice">{{ notice }}</p>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
</aside>
<div ref="containerRef" class="three-container"></div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const containerRef = ref<HTMLDivElement | null>(null);
// 目标节点名:来自 glb 内部 Object3D.name(建议先“打印节点树”再复制)
const targetNodeName = ref('');
// 滑块角度(deg):更符合课堂直觉;写进 rotation 前会换算成 rad
const angleDeg = ref(0);
// notice:用于“正常提示”(例如操作成功、下一步建议)
const notice = ref('');
// errorMessage:用于“错误提示”(例如找不到节点名)
const errorMessage = ref('');
// loading:控制按钮禁用/状态显示,避免重复触发加载
const loading = ref(false);
// Three.js 核心对象:与 Vue 响应式解耦,避免频繁 re-render
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let modelRoot: THREE.Object3D | null = null;
// 节点索引:name -> Object3D,用于把“关节名/节点名”快速映射到可控制对象
let nodeIndex: Map<string, THREE.Object3D> = new Map();
function degToRad(deg: number) {
// Three.js rotation 用弧度
return (deg * Math.PI) / 180;
}
function resize() {
const container = containerRef.value;
if (!container || !renderer || !camera) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) return;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate() {
if (!renderer || !scene || !camera) return;
renderer.render(scene, camera);
rafId = requestAnimationFrame(animate);
}
function createBaseScene(container: HTMLDivElement) {
// 与前两个实验保持一致的场景骨架:方便课堂对照与复用排错经验
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
camera = new THREE.PerspectiveCamera(60, 1, 0.1, 150);
camera.position.set(4, 3, 6);
camera.lookAt(0, 1, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
scene.add(new THREE.AxesHelper(2));
scene.add(new THREE.GridHelper(10, 10));
const ambient = new THREE.AmbientLight(0xffffff, 0.8);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(6, 8, 4);
dirLight.castShadow = true;
scene.add(ambient, dirLight);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(12, 12),
new THREE.MeshLambertMaterial({ color: 0x1f2937 }),
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
}
function buildNodeIndex(root: THREE.Object3D) {
// 把“遍历整棵树找节点”提前做成索引:后续每次更新角度都能 O(1) 查找
const index = new Map<string, THREE.Object3D>();
root.traverse((obj) => {
if (!obj.name) return;
index.set(obj.name, obj);
});
return index;
}
function dumpObjectTree(root: THREE.Object3D) {
// 输出 type + name:用来判断导出时是否保留了层级/命名(联动能否做起来的关键)
const lines: string[] = [];
root.traverse((obj) => {
const type = obj.type || 'Object3D';
const name = obj.name || '(no-name)';
lines.push(`${type} | ${name}`);
});
console.log(lines.join('\n'));
}
function applyAngle() {
if (!modelRoot) return;
errorMessage.value = '';
notice.value = '';
if (!targetNodeName.value) {
// 没有目标名时不给 silent fail:直接在面板提示下一步操作
notice.value = '请先填写目标节点名(建议先点“打印节点树”)。';
return;
}
// 通过索引表按 name 取节点:这一步就是“未来 joint_states 映射”的雏形
const node = nodeIndex.get(targetNodeName.value);
if (!node) {
errorMessage.value = `找不到节点名:${targetNodeName.value}(检查导出时是否保留 name,或节点是否被改名/打平)`;
return;
}
// 最小验证:固定绕 Z 轴旋转(真实项目旋转轴应来自 URDF joint axis / tf)
node.rotation.z = degToRad(angleDeg.value);
notice.value = `已对 ${targetNodeName.value} 应用角度 ${angleDeg.value}°(绕 Z 轴)`;
}
function clearModel() {
if (!scene || !modelRoot) return;
// 从场景树上移除模型(“看不见”)
scene.remove(modelRoot);
// 课堂版:这里只移除引用与索引;严格释放可参考前两个实验的 disposeSceneMeshes/Set 去重
modelRoot = null;
nodeIndex = new Map();
}
async function loadModel() {
if (!scene) return;
// 重新加载前先清掉旧模型:避免叠加多个 root
clearModel();
loading.value = true;
errorMessage.value = '';
notice.value = '';
try {
const loader = new GLTFLoader();
const modelUrl = `${import.meta.env.BASE_URL}models/robot-arm.glb`;
// loadAsync:用 async/await 组织,错误通过 try/catch 进入 UI
const gltf = await loader.loadAsync(modelUrl);
modelRoot = gltf.scene;
modelRoot.position.set(0, 0, 0);
modelRoot.scale.setScalar(1.2);
modelRoot.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
mesh.castShadow = true;
mesh.receiveShadow = true;
});
// 建立 name -> Object3D 索引(关键步骤):没有稳定 name,后续 joint 映射会非常痛苦
nodeIndex = buildNodeIndex(modelRoot);
scene.add(modelRoot);
notice.value = '模型加载完成。建议点击“打印节点树”,再选择一个有 name 的节点做旋转验证。';
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '模型加载失败,请检查 public/models/robot-arm.glb 是否存在';
} finally {
loading.value = false;
}
}
function dumpTree() {
if (!modelRoot) return;
dumpObjectTree(modelRoot);
notice.value = '已在控制台输出节点树(type | name)。';
}
function reload() {
void loadModel();
}
watch([angleDeg], () => {
// 滑块变化就立即应用角度:模拟“实时数据驱动”(后续把 angleDeg 替换成 ROS2 joint_states 即可)
applyAngle();
});
onMounted(() => {
const container = containerRef.value;
if (!container) throw new Error('Three container not found');
createBaseScene(container);
resize();
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(container);
void loadModel();
animate();
});
onBeforeUnmount(() => {
if (rafId !== null) cancelAnimationFrame(rafId);
if (resizeObserver && containerRef.value) resizeObserver.unobserve(containerRef.value);
clearModel();
renderer?.dispose();
if (renderer?.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
resizeObserver = null;
rafId = null;
camera = null;
scene = null;
renderer = null;
});
</script>
<style scoped>
.page {
display: grid;
grid-template-columns: 340px 1fr;
gap: 12px;
}
.panel {
background: #0f172a;
color: #e5e7eb;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 12px;
}
.three-container {
width: 100%;
min-height: 560px;
border: 1px solid #1f2937;
border-radius: 8px;
overflow: hidden;
}
.hint {
color: #94a3b8;
}
.row {
display: grid;
gap: 6px;
margin-top: 10px;
}
.input {
border: 1px solid #1f2937;
border-radius: 8px;
padding: 8px;
background: #0b1220;
color: #e5e7eb;
}
.actions {
display: grid;
gap: 8px;
margin-top: 12px;
}
.btn {
border: 1px solid #1f2937;
border-radius: 10px;
padding: 10px 12px;
background: #111827;
color: #e5e7eb;
cursor: pointer;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.value {
color: #cbd5e1;
}
.notice {
color: #93c5fd;
}
.error {
color: #fca5a5;
}
</style>
解释(保证它“能跑起来”):
- 把文件保存为
src/components/GltfJointPrepLab.vue,并在App.vue中取消注释对应 import 与组件标签。 - 确保
public/models/robot-arm.glb存在,否则会显示加载失败提示。 - 推荐先点“打印节点树”,再把控制台里出现的某个 name 复制到“目标节点名”输入框中。
当你遇到“加载失败”或“加载成功但看不见”,按下面顺序查,效率最高:
- 先查路径
- 文件名是否拼错
- 是否真的放在
public/models/下 .gltf对应的.bin和贴图文件是否都在正确位置
- 再查看浏览器网络请求
- 是否出现
404 - 是否资源请求被拦截
- 是否请求到了 HTML 页面而不是模型文件
- 再查模型是否加入场景
onLoad是否执行scene.add(root)是否真的被调用
- 再查模型的可见性
- 缩放是否过大或过小
- 位置是否跑出相机视野
- 模型是否沉到地面下方
- 最后查光照与材质
- 是否有基础光照
- 模型材质是否需要更明显的方向光
- 是否因为背景色和模型颜色太接近导致“看似没加载”
路径错,通常会触发
onError;位置和缩放错,通常会“加载成功但看不见”;这两类问题不要混在一起查。
本讲只讲“最基础、最容易落地”的优化,不追求高级压缩插件:
- 单文件更利于传输和部署,减少漏文件、路径错和多资源碎片化问题。
- 控制模型体积
- 学生作业阶段优先选择中低面数模型,先保证稳定可见,再谈高精度。
- 一个经验:先能稳定加载,再慢慢换更精细版本,不要一上来就用超大资源。
- 像素比封顶
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))可以显著减少高分屏设备上的额外渲染压力。
- 按需加载
- 首屏先加载最关键的模型,次要模型可以延后;不要把所有资源都压到页面初始化瞬间。
- 重复模型优先复用
- 同一个分拣箱、同一批托盘、同一类工位零件,如果结构相同,不要重复下载多个不同文件。
- 进阶做法是“加载一次,再 clone 多份”,本讲只先建立这个意识。
- 离开页面及时释放资源
- 模型加载成功只是开始,真正工程化要做到“进得来,也退得干净”。
1) 加载成功率测试
- 用例:连续加载 3 个工业模型
- 期望:至少 2 个成功,失败项必须有明确提示
- 价值:验证基础路径与资源组织是否正确
2) 加载时间测试
- 用例:记录每个模型的
durationMs - 价值:帮助发现异常大文件或资源组织问题
3) 失败提示测试
- 用例:故意把一个路径写错
- 期望:界面出现失败提示,而不是静默无反馈
- 价值:验证
onError是否真正接入 UI
4) 场景适配测试
- 用例:加载完成后检查模型是否落在地面附近、是否位于相机可视范围内
- 期望:模型“看得见、摆得正、比例合理”
- 价值:验证位置、缩放和相机关系,而不是只看加载是否成功
5) 卸载释放测试
- 用例:进入页面、离开页面、再次进入页面
- 期望:控制台无明显报错,页面不卡顿,不残留旧画布
- 价值:验证资源释放是否完善
七、项目工坊(整合版:工业模型加入现有 3D 场景)
本次项目工坊与学生任务统一口径:
- 加载工业机器人、分拣传送带等基础 GLB 模型
- 添加加载进度提示
- 处理加载失败场景
- 将模型添加到现有 3D 场景并调整位置、缩放
- 扩展(为后续关节联动铺路):输出模型节点树,建立至少 1 个“节点名 → Object3D”的映射,并用滑块模拟局部旋转
建议完成顺序:
- 先用一个
.glb跑通最小加载流程 - 再增加第二个模型,验证场景摆放是否合理
- 再补“进度 / 成功 / 失败”状态
- 最后再做多模型结果统计与简单测试
- 可选:做一次“关节联动预埋”验证(打印节点树 → 找到一个节点 → 用滑块让它按角度旋转)
- 至少 2 个工业相关模型成功显示
- 页面上能看到加载过程信息,而不是纯黑盒加载
- 故意制造 1 个失败场景后,界面能明确提示问题
- 模型之间位置不重叠、缩放基本合理、与地面关系正常
- 可选:能在控制台输出模型节点树,且能让一个局部节点随滑块变化旋转(证明后续关节联动具备资产基础)
九、大模型任务(可直接复制使用)
任务 1:AI 生成 GLB / GLTF 模型加载完整代码
请为 Vue3 + Vite + TypeScript + Three.js 生成一个可运行的 GLB / GLTF 模型加载组件,要求:
1) 使用 GLTFLoader 从 public/models/ 目录加载模型;
2) 至少包含一个 .glb 示例路径;
3) 页面上显示加载状态、加载进度、加载成功提示和加载失败提示;
4) 模型加载后要添加到场景,并能设置 position 与 scale;
5) 包含 onBeforeUnmount 的资源释放;
6) 输出完整的 .vue 文件代码,并逐段解释关键逻辑。
期望输出校验点:
- 是否正确从
three/examples/jsm/loaders/GLTFLoader.js导入 - 是否使用了
gltf.scene - 是否包含
onError和 UI 状态展示 - 是否包含资源释放逻辑
任务 2:推荐适合工业分拣产线的 3D 模型资源
请推荐适合工业分拣产线可视化教学的 3D 模型资源方向,要求覆盖:
1) 工业机器人 / 机械臂;
2) 分拣传送带;
3) 托盘、分拣箱、工作台等基础设备;
4) 优先推荐适合导出为 GLB / GLTF 的资源类型。
请用表格输出“模型类别、建议用途、课堂难度、资源选择建议”。
期望输出校验点:
- 是否区分“主设备模型”和“场景辅助模型”
- 是否明确建议优先使用可直接转为 GLB 的资源
任务 3:讲解模型加载失败的常见原因及排查方法
我正在用 Three.js 的 GLTFLoader 加载工业模型,请按“最短排查路径”列出模型加载失败或加载成功但看不见的常见原因,要求覆盖:
1) 路径错误;
2) gltf 关联资源缺失;
3) 位置或缩放不合理;
4) 相机视野问题;
5) 光照或材质导致看不清;
6) 资源释放不当导致重复进入页面越来越卡。
请输出为 checklist,并给出每一项的验证方法。
期望输出校验点:
- 是否把“加载失败”和“加载成功但不可见”区分开
- 是否给出可操作的验证方法
- 是否体现工程化排错顺序,而不是泛泛而谈
任务 4(扩展):AI 生成“关节联动预埋”最小示例代码(不接 ROS2)
我想为后续“机器人关节与前端模型联动”做准备,请为 Vue3 + Vite + TypeScript + Three.js 生成一个最小可运行示例,要求:
1) 用 GLTFLoader 加载一个 robot-arm.glb;
2) 加载完成后打印 gltf.scene 的节点树(输出 type + name);
3) 建立 name -> Object3D 的索引 Map;
4) 页面提供一个滑块(0~180 度),滑块变化时驱动某个指定 name 的节点绕 Z 轴旋转;
5) 如果找不到该 name,要在页面上给出明确提示(而不是静默失败)。
请输出完整的 .vue 文件代码,并解释每个关键步骤的目的。
期望输出校验点:
- 是否正确使用
gltf.scene.traverse(...)输出节点树 - 是否用
Map做节点索引而不是每次都全量遍历 - 是否把角度(deg)转换为弧度(rad)再写入 rotation
- 是否考虑“节点名缺失/找不到节点”的失败提示
课后作业(布置)
- 完成至少 2 个工业 3D 模型(如机械臂、分拣机)的加载,实现加载进度监听与失败处理。
提交要求:
- 提交至少 1 个模型加载组件或核心代码截图。
- 提交 1 张能看出两个模型同时存在的场景截图。
- 截图模型加载完成后的场景效果,标注加载流程关键代码。
提交要求:
- 截图中需标注至少 3 个关键点:
GLTFLoader引入、loader.load或loadAsync、scene.add(gltf.scene)。
- 撰写 150 字左右说明,简述 GLB / GLTF 模型的优势及加载过程中需要注意的问题。
评分要点(参考):
- 是否能区分 GLB 与 GLTF 的基本差异
- 是否真正实现进度监听与错误处理,而不是只完成“能显示”
- 是否能解释模型位置、缩放、路径组织这三个高频问题
参考与延伸
- Three.js GLTFLoader:https://threejs.org/docs/#examples/en/loaders/GLTFLoader
- Three.js LoadingManager:https://threejs.org/docs/#api/en/loaders/managers/LoadingManager
- glTF 官方站点:https://www.khronos.org/gltf/
- Three.js Object3D:https://threejs.org/docs/#api/en/core/Object3D
- Three.js Scene:https://threejs.org/docs/#api/en/scenes/Scene
自检清单(发布前已检查)
- Markdown 标题层级连续:从
#到## / ### / ####逐级展开,无跳级。 - 所有代码块均已闭合,并标注了语言标签(
bash/ts/vue/text)。 - 术语保持一致:统一使用
GLB / GLTF、Three.js、GLTFLoader、gltf.scene、Vue3 + Vite + TypeScript。 - 命令已核对:
npm i three为可执行命令,未引入多余依赖。 - 代码结构已自检:导入路径、
load/loadAsync用法、scene.add(...)、onBeforeUnmount释放逻辑均保持前后一致。 - 内容衔接已处理:不重复讲几何体与光照原理,明确承接
26-28并为后续真实工业模型展示铺路。